iT邦幫忙

2024 iThome 鐵人賽

DAY 3
0
Security

Pwn2Noooo! 執行即 Crash 的 PWNer 養成遊戲系列 第 3

[Day3] 基礎知識 - 執行檔如何被執行

  • 分享至 

  • xImage
  •  

在上一篇文章中,我們介紹了程式如何從原始碼變成可執行檔。現在,我們將重點放在執行檔在 Linux 系統上如何被執行,即如何將這些機器碼載入記憶體並交由 CPU 執行。

本篇文章的架構如下:

  • ELF 檔案格式
    • ELF Header
    • Program Headers
    • Sections & Section Headers
  • 載入可執行檔
    • Program Headers
    • 動態連結
    • 程式記憶體配置
  • 程式執行
    • _start
    • __libc_start_main()

本篇內容的範例執行檔為 hello,原始程式為上篇文章中的 hello.c。
編譯指令為 gcc -o hello hello.c,預設為動態連結。

在 Linux 系統中,可執行檔案通常以 ELF(Executable and Linkable Format)格式儲存。ELF 是檔案格式,除了可儲存可執行檔,還用於儲存組譯後的目標檔案(Object File)和共享函式庫(.so 檔案)。

補充 1:
電腦中的每個檔案本質上都是由一連串的二進位數字組成,不同類型的檔案(如 exe、pdf、jpg)會依照特定格式,將重要資訊存放在這串數字中的特定位置,使得用來打開它的軟體能夠知道如何解析和讀取這些檔案的內容。例如,當一個圖片編輯器試圖打開一個 jpg 檔案時,編輯器程式會根據這個格式知道圖片的寬度、高度以及顏色等內容。

補充 2:
許多檔案,如 pdf 檔的檔案儲存格式就是 PDF(Portable Document Format)。然而 Windows 中的可執行檔(exe)並非檔案格式的名稱,exe 檔是用一種叫做 PE (Portable Executable) 的檔案格式儲存。PE 這個檔案格式如同 ELF,除了能儲存執行檔還能儲存組譯後的目標檔案(Object File)和共享函式庫(在 Windows 中為 .dll)

ELF 檔案格式

ELF 這種檔案格式可以簡單分為四個區塊,分別為 ELF Header 、Program Headers、Sections、Section Headers。

難以理解檔案格式概念的讀者可以參考以下這張圖:
image
(Tiny ELF Files: Revisited in 2021)

當我們使用任何可以查看二進制檔案的工具(xxd、hd、HxD)開啟 ELF 格式的檔案後,可以看到一連串以十六進制表現的二進制檔案,可以看到圖中將 ELF 格式分成幾大區塊,這就是我們接下來要介紹的 ELF 格式內容。

註:實際上用 gcc 編譯上篇文章的 hello.c 產成出的執行檔內容跟上圖有些許差異

移除掉二進制內容,僅留下區塊名稱後,整個 ELF 檔架構如下。
image

ELF Header

ELF Header 包含檔案基本資訊,如檔案類型、目標架構、進入點位址等。此外,還記錄了 Program Headers 和 Section Headers 的位置及大小。
我們可以使用以下指令來查看 hello 的 ELF Header 中較為重要的內容
readelf -h hello
image

可以注意到它告訴我們 Program Headers 的位置從檔案中的 64 bytes 開始(即 0x40),而 Section Headers 的位置則是從檔案中的 14056 bytes 開始(即 0x3690),以及在紅框的最後一行可以看到 Entry point address 為 0x1050,這表示程式在載入到記憶體後會跳轉到此位址開始執行。

補充 1:前面有提到 ELF 格式不只能儲存可執行檔,檔案基本資訊中的 Type 就是用來辨認此份檔案是是什麼類別。然而我們會發現 hello 明明是執行檔但卻不是顯示 EXEC 而是顯示代表共享函式庫的 DYN。這是因為由於 gcc 會預設開啟一種程式保護機制,為了達到此保護機制,它將類型設為 DYN,並且在後面補充他是某種執行檔(Position-Independent Executable file)。可以參考此份回答

補充 2:關於此保護機制與 Position-Independent 的意思會在後續的文章中提到

補充 3:真正的共享函式庫的 Type 欄位為 DYN (Shared object file)

Program Headers

Program Headers 主要用於讓載入器知道如何將此程式載入至記憶體,稍後我們將詳細說明。

Sections & Section Headers

ELF 檔案透過名為 Section 的形式將檔案內容區分,一個 ELF 裡有許多不等數量的 Sections。一個 ELF 有多少 Sections 會標示於 ELF Header 裡,以我們的 hello 為例,種共有 31 個 Sections。
image
我們透過以下命令查看 Section Headers
readelf -S hello
image
會區分不同的 Sections 主要是編譯時期方便連結器參照的,以下表格列出這些 Sections 的說明。

No Sections 說明
1 .interp 程式載路器的路徑
2、3、4 .note 附註資訊
5、8、9 .gun Symbol Table 會用到的資訊
6 .dynsym 動態連結用符號表
7 .dynstr 動態連結用字串表
10、11 .rela 重定位資訊
12、20 .init、.init_array 主程式執行前會執行此段落
13、14、23、24 .plt & .got 用於處理動態連結(註1)
15 .text 程式碼
16、21 .fini、.fini_array 程式執行後會執行此段落
17 .rodata 唯獨資料
18、19 .eh_frame_hdr、.eh_frame 例外處理的資訊
22 .dynamic 動態連結資訊
25 .data 已初始化資料
26 .bss 未初始化的全域和靜態變數
27 .comment 版本控制資訊
28 .symtal 符號表(Symbol Table)
29 .strtab 字串表
30 .shstrtab 儲存 Section 名稱

其中,有幾個 Sections 可以直觀的從程式碼中看到,分別是 .text.data.bss.rodata
image

註1:plt 與 got 的內容牽涉到動態連結機制,將在後續文章中深入討論

載入可執行檔

當我們在 Shell 中輸入 ./hello 指令時,Shell 會幫我們調用作業系統的系統呼叫 execve() 來執行此程式。作業系統首先會讀取該檔案的 Header 資訊,確認它是 ELF 檔案後,接著讀取 Program Headers,並根據這些資訊將檔案載入記憶體中。

Program Headers

透過下面指令,我們可以看到 Program Headers 的內容
readelf -l hello
image
對於載入器來說,ELF 檔案的重點已不再是 Sections,而是 Segments。Segment 是由多個 Sections 組合而成的單位,從上圖的下半部分可以看到哪些 Sections 被合併到同一個 Segment 中。這些 Segments 對應到上半部分的 Segments 類型,其中我們最關注的是類型為 LOAD 的 Segments,因為它們代表實際會被載入到記憶體的部分。

每個 Segment 都會記錄它在檔案中的位置、大小,以及它應該被載入到記憶體的哪個位置。根據這些資訊,載入器就能將程式載入記憶體,並依照屬性欄位來設置每個 Segment 是否可讀(R)、可寫(W)或可執行(E)。

動態連結

除了根據 Program Headers 將 Segments 載入到記憶體之外,若程式依賴於動態連結的共享函式庫,還需處理連結的過程。動態連結器會根據 ELF 檔案中的 DYNAMIC Segment 來定位並載入必要的共享函式庫,然後將程式執行所需的函數載入到記憶體中,這樣在執行時程式可以正確呼叫這些外部函數。

程式記憶體配置

image
程式載入記憶體後,記憶體佈局會依據不同的 Segments 進行分配,如上圖所示,右邊顯示了程式執行後在記憶體中的排列順序。除了先前提到的 Segments 外,還可以看到 Stack(堆疊)和 Heap(堆積)。Stack 和 Heap 分別用於分配暫時性資料和動態資料,這兩個區域會在程式執行過程中動態變化,後續的文章中也會詳細介紹我們會如何依據它的特性做攻擊。

程式執行

當程式載入記憶體後,它便可以開始執行。由於我們的範例使用了動態連結,程式的起始點(Entry Point)不會立即執行主程式,而是會先進入動態連結器(Dynamic Linker)。動態連結器會根據 DYNAMIC Segment 中的資訊,載入所有依賴的共享函式庫,並做一些初始化動作。

完成動態連結後,程式的控制權會被交還給主程式,從 Entry Point 開始執行。不過,需要注意的是,實際的 Entry Point 並不是我們在程式中編寫的 main() 函數,而是 _start

_start

_start 是每個 ELF 可執行檔的真正起點。它負責初始化程式的執行環境,包括設定指標、準備參數等。接著,_start 會呼叫 __libc_start_main() 函數。

__libc_start_main()

__libc_start_main() 是 C 標準函式庫(libc)中的一個核心函數。它會先執行程式中的初始化函數,例如前面提到的 init Section 中的函數。這些函數通常負責全域變數、靜態物件的初始化,以及其他必要的準備工作。

完成這些初始化動作後,__libc_start_main() 才會將控制權交給我們所編寫的 main() 函數,這時程式的主要邏輯才真正開始執行。

main() 函數結束時,__libc_start_main() 會負責清理工作,它會呼叫 fini Section 中的函數,這些函數專門處理程式結束前的收尾工作,像是釋放資源、關閉文件等。

在下一篇文章中,我們將深入探討程式執行期間 Stack 的作用



上一篇
[Day2] 基礎知識 - 程式如何被編譯
下一篇
[Day4] Stack 介紹
系列文
Pwn2Noooo! 執行即 Crash 的 PWNer 養成遊戲13
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言